iT邦幫忙

2022 iThome 鐵人賽

DAY 8
2
Software Development

軟體架構師的自我修養系列 第 8

[Day 8] 從單體演化成CQRS實戰

  • 分享至 

  • xImage
  •  

軟體設計是一個演化的過程。

所有大型系統都是從一個小系統開始的。當碰到無法解決的問題,系統就會開始演化。每一次的演化都伴隨許多技術選型,要解決什麼問題?有什麼代價?

身為一個架構師,我們必須要找到一個合理的演化方向,無論是根據開發排程、技術堆疊和團隊水平,在一個解法出來時總是要能滿足諸多條件。

這篇文章會介紹CQRS (Command Query Responsibility Segregation)和其要解決的問題。我們會從一個小型單體開始,不斷演化他,就像我們演化每個現實中的系統般,也會仔細解釋每次演化的理由。

傳統單體

這是一個最常見的系統設計。

有一個API通常是RESTful和一個資料庫。客戶端透過預先定義好的傳輸格式和後端系統溝通。讀取和寫入都是透過DTO(data transfer object)這個資料傳輸物件來進行。

但是當後端要處理商業邏輯時,後端要將DTO轉換成具有領域能力的領域物件,並且將領域物件作為資料庫的儲存單元。

為了做到讀寫分離,在左邊的寫入路徑,客戶端透過DTO對資料庫執行新增、修改、刪除的操作,而後端會回應Ack表示成功和Nak表示失敗。在RESTful的定義下通常以2xx表示成功和4xx表示失敗。

至於右邊的讀取路徑,則是單純的透過請求取得對應的DTO。

讓我進一步解釋DTO對客戶端的意義。DTO通常包含所有需要用來呈現的資料,舉例來說,在社群網站中看自己的檔案會包含帳號、暱稱與其他個人資訊,同時也會包含最近動態甚至追蹤者的動態。DTO包含所有需要呈現在這頁的資料。

為什麼我們需要強調讀寫分離?難道不能讓讀取和寫入都有相同的操作方式嗎?例如寫入也是透過DTO當作回傳結果。

因為我們希望在未來能夠更容易進行最佳化,無論讀取或寫入都有屬於自己的獨特最佳化方式。舉例來說,就算要做快取,在讀取路徑上使用的是旁讀(read-aside)快取,在寫入路徑上則是透過快取(write-through)寫入。

其次,寫入路徑也可以是非同步的,所有的DTO都被寫進訊息佇列中,由訊息處理者進行批次處理。更有甚者,讀取路徑和寫入路徑所適合的資料儲存也不相同。

因此,讀寫分離是必要的。而且這必須要在系統設計早期儘早考慮。寫入路徑應該專注在資料的持久化,至於讀取路徑則專門負責資料查詢。

儘管如此,這個設計依然有兩個主要問題。

  1. 貧血(Anemic)模型,這也被稱為CRUD模型。當後端系統只專注在資料轉換時,就很難有餘力去處理商業邏輯,導致商業邏輯散在程式碼各處,領域知識也隨之消失。例如:一個電子商務網站我們會說「下單」而不是「插入訂單記錄」。
  2. 欠缺擴充性。從系統架構的角度來看,資料庫很容易變成效能瓶頸,無論讀寫都壓在上面。如果是關連式資料庫問題又更嚴重,因為關連式資料庫欠缺好的水平擴展能力。

基於任務的單體

為了解決傳統單體碰到的問題,我們嘗試引入領域的概念。

這圖形和剛剛的傳統單體大致相同,唯一差別在於寫入路徑上的DTO變成發送訊息。訊息包含了動作和資料,而不像DTO僅有資料。因此我們可以攜帶領域知識在訊息中,讓後端系統更容易理解對應的領域操作。

到這個階段,CQRS的C已經出現了,訊息就是一種命令。但是,擴充性的問題依然無解。

此外,雖然我們單純地將DTO變成訊息,我們在讀取路徑依然需要DTO。以一個社群平台為例,當我們要修改暱稱時會透過{"rename": "LazyDr"}這則訊息,但當要呈現個人檔案時,還是需要額外資訊,諸如動態等。這段資訊落差造成讀取路徑要做更多處理才有能力生成DTO。

命令查詢分離(CQS)

為了解決上述讀寫分離的痛點,我們引入CQS。

當要讀取時,客戶端需要DTO,因此後端系統將DTO預先建立並儲存在負責讀取的資料庫。

如此一來,在讀取路徑上應用程式的實作就變單純了。應用程式會變成一個讀取層,只需要處理排序、分頁等問題。客戶端發出請求後,可以很輕易的就拿到DTO。

那麼問題來了,誰要負責預先建立這些DTO呢?這都交由寫入路徑處理。

雖然這張圖看起來跟之前大同小異,事實上,除了儲存領域物件之外,應用程式還必須儲存DTO。也就是說,所有的商業邏輯都會放在寫入路徑上,寫入路徑要準備不同功能所需要的不同DTO。

到這階段,我們已經解決大部分碰到的問題,但擴充性還是個問題。

讓我們更進一步定義擴充性,這包含兩個層面:

  1. 網路流量:在寫入數量龐大的DTO時會面臨挑戰。
  2. 功能擴充:當功能需求增加,例如需要更多不同的呈現方式,會讓寫入路徑的邏輯更加複雜並且難以維護。再以社群網站為例,在看個人檔案時需要一種呈現方式,但在看時間軸或動態時又有另一種呈現。

命令查詢職責分離(CQRS)

為什麼寫入路徑要負責準備各種呈現方式?

正如一開始說的,寫入路徑應該專注在資料持久化。但讀取路徑也只專注在拉取資料,那到底誰該準備那些DTO?

因此,最終解法如下:

左邊的寫入路徑和右邊的讀取路徑都和CQS一樣,唯一的差別在於最上方新增加一個「最終一致性」的區塊。

這個最終一致性區塊負責將寫入路徑上的資料庫內容轉換成讀取路徑上的資料。一但在系統中出現資料同步,勢必會有資料一致性的問題。因此以下以一致性的時間間隔由短至長列出幾種常見的實作方式:

  1. 背景執行緒:這做法典型的代表是Redis。當有資料寫入Redis的主執行緒,Redis會馬上起一條背景執行緒將資料複製給資料副本。
  2. 訊息佇列和處理者:這是非同步資料複製最常見的做法。當寫資料進資料庫,同時發起一個訊息進訊息佇列,由處理者進行資料轉換。
  3. ETL(Extract-Transform-Load):這個時間間隔是最長的,從數分鐘至數小時,透過map-reduce之類的技術將資料從一邊遷移至另一邊。

無論哪種作法,單一信任來源(single source of truth)都是必要的,也就是說,系統在錯誤發生時必須要有能力能夠復原。因此,資料必須唯一且可靠。

資料通常用兩種型態存在:

  1. 狀態:狀態表示資料當下看到的樣子,例如存摺內的銀行餘額。
  2. 事件:事件是修改狀態的動作,例如存摺內的每筆交易紀錄。

事實上,我們已經可以將訊息儲存下來作為事件了。對於寫入路徑來說,循序儲存訊息是非常有效率的。經由每個不同的訊息,我們可以簡單地根據需求建立各種呈現方式,這樣的做法也稱為事件朔源(event sourcing)。

但光只有事件很難有效運用,為了要得到當下狀態,就得要將事件從頭走到尾,因此除了保留事件外,同時也保留狀態的雙軌制會更理想。在寫入路徑上,事件和狀態同時保留下來,而轉換成DTO的過程則可以根據需求自行決定要取用哪種資料形式。

總結一下資料在CQRS中的生命週期。

資料從客戶端開始,接著以命令的形式進入後端系統。在後端系統中,根據領域知識和商業邏輯將命令轉成對應的領域物件,同時也以事件的形式進行轉換,轉換成各種不同的呈現方式。最後,客戶端則是以DTO的方式取得呈現資料。

結論

有許多書籍和文章都用各種「術語」描述領域驅動設計和CQRS。我認為,那些術語例如Entity、Value Object或Aggregation等會限制關於領域驅動的想像力。讓人們覺得領域驅動設計很遙不可及並且難以實現,事實上,領域驅動設計的概念沒那麼複雜。

其實領域驅動設計就是把各種商業邏輯封裝,以便更好擴展功能需求。

至於CQRS就更單純了。在這篇文章中,我們從系統的演化過程中了解整個系統設計需要解決的問題,最後自然就推導出CQRS。

在系統設計中沒有所謂銀彈,每一次演化都是為了解決特定的問題,但也會帶來新的問題。以這篇文章的例子來說,CQRS看起來解決所有我們碰到的問題:貧血模型和欠缺擴充性,但事實上,CQRS也帶來了新的問題,例如資料一致性。

每一個技術選型都伴隨取捨,只要了解每個選項背後的風險,就可以找到相對可接受的方案。即便選擇CQRS,實務上,還有許多實作最終一致性的方案要考慮。系統設計就是一連串的選擇。

這篇文章的目的是要告訴你,領域驅動設計並不可怕,而CQRS也不複雜,都只是選擇而已。


上一篇
[Day 7] 分散式交易設計實戰
下一篇
[Day 9] 訊息佇列的各種考量
系列文
軟體架構師的自我修養31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言